// Copyright 2025 The Atlantis Authors
// SPDX-License-Identifier: Apache-2.0

package events_test

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"
	"net/http/httptest"
	"os"
	"os/exec"
	"path/filepath"
	"regexp"
	"strings"
	"testing"

	"github.com/google/go-github/v71/github"
	"github.com/hashicorp/go-version"
	. "github.com/petergtz/pegomock/v4"

	"github.com/runatlantis/atlantis/server"
	events_controllers "github.com/runatlantis/atlantis/server/controllers/events"
	"github.com/runatlantis/atlantis/server/core/boltdb"
	"github.com/runatlantis/atlantis/server/core/config"
	"github.com/runatlantis/atlantis/server/core/config/valid"
	"github.com/runatlantis/atlantis/server/core/locking"
	"github.com/runatlantis/atlantis/server/core/runtime"
	runtimemocks "github.com/runatlantis/atlantis/server/core/runtime/mocks"
	"github.com/runatlantis/atlantis/server/core/runtime/policy"
	mock_policy "github.com/runatlantis/atlantis/server/core/runtime/policy/mocks"
	"github.com/runatlantis/atlantis/server/core/terraform"
	terraform_mocks "github.com/runatlantis/atlantis/server/core/terraform/mocks"
	"github.com/runatlantis/atlantis/server/core/terraform/tfclient"
	"github.com/runatlantis/atlantis/server/events"
	"github.com/runatlantis/atlantis/server/events/command"
	"github.com/runatlantis/atlantis/server/events/mocks"
	"github.com/runatlantis/atlantis/server/events/models"
	"github.com/runatlantis/atlantis/server/events/vcs"
	vcsmocks "github.com/runatlantis/atlantis/server/events/vcs/mocks"
	"github.com/runatlantis/atlantis/server/events/webhooks"
	jobmocks "github.com/runatlantis/atlantis/server/jobs/mocks"
	"github.com/runatlantis/atlantis/server/logging"
	"github.com/runatlantis/atlantis/server/metrics/metricstest"
	. "github.com/runatlantis/atlantis/testing"
)

// In the e2e test, we use `conftest` not `conftest$version`.
// Because if depends on the version, we need to upgrade test base image before e2e fix it.
const conftestCommand = "conftest"

var applyLocker locking.ApplyLocker
var userConfig server.UserConfig

type NoopTFDownloader struct{}

var mockPreWorkflowHookRunner *runtimemocks.MockPreWorkflowHookRunner

var mockPostWorkflowHookRunner *runtimemocks.MockPostWorkflowHookRunner

func (m *NoopTFDownloader) Install(_ string, _ string, _ *version.Version) (string, error) {
	return "", nil
}

type LocalConftestCache struct {
}

func (m *LocalConftestCache) Get(_ *version.Version) (string, error) {
	return exec.LookPath(conftestCommand)
}

func TestGitHubWorkflow(t *testing.T) {

	if testing.Short() {
		t.SkipNow()
	}
	// Ensure we have >= TF 0.14 locally.
	ensureRunning014(t)

	cases := []struct {
		Description string
		// RepoDir is relative to testdata/test-repos.
		RepoDir string
		// RepoConfigFile is path for atlantis.yaml
		RepoConfigFile string
		// ModifiedFiles are the list of files that have been modified in this
		// pull request.
		ModifiedFiles []string
		// Comments are what our mock user writes to the pull request.
		Comments []string
		// ApplyLock creates an apply lock that temporarily disables apply command
		ApplyLock bool
		// AllowCommands flag what kind of atlantis commands are available.
		AllowCommands []command.Name
		// DisableAutoplan flag disable auto plans when any pull request is opened.
		DisableAutoplan bool
		// DisablePreWorkflowHooks if set to true, pre-workflow hooks will be disabled
		DisablePreWorkflowHooks bool
		// ExpAutomerge is true if we expect Atlantis to automerge.
		ExpAutomerge bool
		// ExpAutoplan is true if we expect Atlantis to autoplan.
		ExpAutoplan bool
		// ExpParallel is true if we expect Atlantis to run parallel plans or applies.
		ExpParallel bool
		// ExpMergeable is true if we expect Atlantis to be able to merge.
		// If for instance policy check is failing and there are no approvals
		// ExpMergeable should be false
		ExpMergeable bool
		// ExpReplies is a list of files containing the expected replies that
		// Atlantis writes to the pull request in order. A reply from a parallel operation
		// will be matched using a substring check.
		ExpReplies [][]string
		// ExpAllowResponseCommentBack allow http response content with "Commenting back on pull request"
		ExpAllowResponseCommentBack bool
		// ExpParseFailedCount represents how many times test sends invalid commands
		ExpParseFailedCount int
		// ExpNoLocksToDelete whether we expect that there are no locks at the end to delete
		ExpNoLocksToDelete bool
	}{
		{
			Description:        "no comment or change",
			RepoDir:            "simple",
			ModifiedFiles:      []string{},
			Comments:           []string{},
			ExpReplies:         [][]string{},
			ExpNoLocksToDelete: true,
		},
		{
			Description:   "no comment",
			RepoDir:       "simple",
			ModifiedFiles: []string{"main.tf"},
			Comments:      []string{},
			ExpReplies: [][]string{
				{"exp-output-autoplan.txt"},
				{"exp-output-merge.txt"},
			},
			ExpAutoplan: true,
		},
		{
			Description:   "simple",
			RepoDir:       "simple",
			ModifiedFiles: []string{"main.tf"},
			Comments: []string{
				"atlantis apply",
			},
			ExpReplies: [][]string{
				{"exp-output-autoplan.txt"},
				{"exp-output-apply.txt"},
				{"exp-output-merge.txt"},
			},
			ExpAutoplan: true,
		},
		{
			Description:   "simple with plan comment",
			RepoDir:       "simple",
			ModifiedFiles: []string{"main.tf"},
			ExpAutoplan:   true,
			Comments: []string{
				"atlantis plan",
				"atlantis apply",
			},
			ExpReplies: [][]string{
				{"exp-output-autoplan.txt"},
				{"exp-output-autoplan.txt"},
				{"exp-output-apply.txt"},
				{"exp-output-merge.txt"},
			},
		},
		{
			Description:   "simple with comment -var",
			RepoDir:       "simple",
			ModifiedFiles: []string{"main.tf"},
			ExpAutoplan:   true,
			Comments: []string{
				"atlantis plan -- -var var=overridden",
				"atlantis apply",
			},
			ExpReplies: [][]string{
				{"exp-output-autoplan.txt"},
				{"exp-output-atlantis-plan-var-overridden.txt"},
				{"exp-output-apply-var.txt"},
				{"exp-output-merge.txt"},
			},
		},
		{
			Description:   "simple with workspaces",
			RepoDir:       "simple",
			ModifiedFiles: []string{"main.tf"},
			ExpAutoplan:   true,
			Comments: []string{
				"atlantis plan -- -var var=default_workspace",
				"atlantis plan -w new_workspace -- -var var=new_workspace",
				"atlantis apply -w default",
				"atlantis apply -w new_workspace",
			},
			ExpReplies: [][]string{
				{"exp-output-autoplan.txt"},
				{"exp-output-atlantis-plan.txt"},
				{"exp-output-atlantis-plan-new-workspace.txt"},
				{"exp-output-apply-var-default-workspace.txt"},
				{"exp-output-apply-var-new-workspace.txt"},
				{"exp-output-merge-workspaces.txt"},
			},
		},
		{
			Description:   "simple with workspaces and apply all",
			RepoDir:       "simple",
			ModifiedFiles: []string{"main.tf"},
			ExpAutoplan:   true,
			Comments: []string{
				"atlantis plan -- -var var=default_workspace",
				"atlantis plan -w new_workspace -- -var var=new_workspace",
				"atlantis apply",
			},
			ExpReplies: [][]string{
				{"exp-output-autoplan.txt"},
				{"exp-output-atlantis-plan.txt"},
				{"exp-output-atlantis-plan-new-workspace.txt"},
				{"exp-output-apply-var-all.txt"},
				{"exp-output-merge-workspaces.txt"},
			},
		},
		{
			Description:   "simple with allow commands",
			RepoDir:       "simple",
			AllowCommands: []command.Name{command.Plan, command.Apply},
			Comments: []string{
				"atlantis import ADDRESS ID",
			},
			ExpReplies: [][]string{
				{"exp-output-allow-command-unknown-import.txt"},
			},
			ExpAllowResponseCommentBack: true,
			ExpParseFailedCount:         1,
			ExpNoLocksToDelete:          true,
		},
		{
			Description:   "simple with atlantis.yaml",
			RepoDir:       "simple-yaml",
			ModifiedFiles: []string{"main.tf"},
			ExpAutoplan:   true,
			Comments: []string{
				"atlantis apply -w staging",
				"atlantis apply -w default",
			},
			ExpReplies: [][]string{
				{"exp-output-autoplan.txt"},
				{"exp-output-apply-staging.txt"},
				{"exp-output-apply-default.txt"},
				{"exp-output-merge.txt"},
			},
		},
		{
			Description:             "simple with atlantis.yaml - autoplan disabled",
			RepoDir:                 "simple-yaml",
			ModifiedFiles:           []string{"main.tf"},
			DisableAutoplan:         true,
			DisablePreWorkflowHooks: true,
			ExpAutoplan:             false,
			Comments: []string{
				"atlantis plan -w staging",
				"atlantis plan -w default",
				"atlantis apply -w staging",
			},
			ExpReplies: [][]string{
				{"exp-output-plan-staging.txt"},
				{"exp-output-plan-default.txt"},
				{"exp-output-apply-staging.txt"},
				{"exp-output-merge.txt"},
			},
		},
		{
			Description:   "simple with atlantis.yaml and apply all",
			RepoDir:       "simple-yaml",
			ModifiedFiles: []string{"main.tf"},
			ExpAutoplan:   true,
			Comments: []string{
				"atlantis apply",
			},
			ExpReplies: [][]string{
				{"exp-output-autoplan.txt"},
				{"exp-output-apply-all.txt"},
				{"exp-output-merge.txt"},
			},
		},
		{
			Description:    "custom repo config file",
			RepoDir:        "repo-config-file",
			RepoConfigFile: "infrastructure/custom-name-atlantis.yaml",
			ModifiedFiles: []string{
				"infrastructure/staging/main.tf",
				"infrastructure/production/main.tf",
			},
			ExpAutoplan: true,
			Comments: []string{
				"atlantis apply",
			},
			ExpReplies: [][]string{
				{"exp-output-autoplan.txt"},
				{"exp-output-apply.txt"},
				{"exp-output-merge.txt"},
			},
		},
		{
			Description:   "modules staging only",
			RepoDir:       "modules",
			ModifiedFiles: []string{"staging/main.tf"},
			ExpAutoplan:   true,
			Comments: []string{
				"atlantis apply -d staging",
			},
			ExpReplies: [][]string{
				{"exp-output-autoplan-only-staging.txt"},
				{"exp-output-apply-staging.txt"},
				{"exp-output-merge-only-staging.txt"},
			},
		},
		{
			Description:             "modules staging only - autoplan disabled",
			RepoDir:                 "modules",
			ModifiedFiles:           []string{"staging/main.tf"},
			DisableAutoplan:         true,
			DisablePreWorkflowHooks: true,
			ExpAutoplan:             false,
			Comments: []string{
				"atlantis plan -d staging",
				"atlantis apply -d staging",
			},
			ExpReplies: [][]string{
				{"exp-output-plan-staging.txt"},
				{"exp-output-apply-staging.txt"},
				{"exp-output-merge-only-staging.txt"},
			},
		},
		{
			Description:   "modules modules only",
			RepoDir:       "modules",
			ModifiedFiles: []string{"modules/null/main.tf"},
			ExpAutoplan:   false,
			Comments: []string{
				"atlantis plan -d staging",
				"atlantis plan -d production",
				"atlantis apply -d staging",
				"atlantis apply -d production",
			},
			ExpReplies: [][]string{
				{"exp-output-plan-staging.txt"},
				{"exp-output-plan-production.txt"},
				{"exp-output-apply-staging.txt"},
				{"exp-output-apply-production.txt"},
				{"exp-output-merge-all-dirs.txt"},
			},
		},
		{
			Description:   "modules-yaml",
			RepoDir:       "modules-yaml",
			ModifiedFiles: []string{"modules/null/main.tf"},
			ExpAutoplan:   true,
			Comments: []string{
				"atlantis apply -d staging",
				"atlantis apply -d production",
			},
			ExpReplies: [][]string{
				{"exp-output-autoplan.txt"},
				{"exp-output-apply-staging.txt"},
				{"exp-output-apply-production.txt"},
				{"exp-output-merge.txt"},
			},
		},
		{
			Description:   "tfvars-yaml",
			RepoDir:       "tfvars-yaml",
			ModifiedFiles: []string{"main.tf"},
			ExpAutoplan:   true,
			Comments: []string{
				"atlantis apply -p staging",
				"atlantis apply -p default",
			},
			ExpReplies: [][]string{
				{"exp-output-autoplan.txt"},
				{"exp-output-apply-staging.txt"},
				{"exp-output-apply-default.txt"},
				{"exp-output-merge.txt"},
			},
		},
		{
			Description:   "tfvars no autoplan",
			RepoDir:       "tfvars-yaml-no-autoplan",
			ModifiedFiles: []string{"main.tf"},
			ExpAutoplan:   false,
			Comments: []string{
				"atlantis plan -p staging",
				"atlantis plan -p default",
				"atlantis apply -p staging",
				"atlantis apply -p default",
			},
			ExpReplies: [][]string{
				{"exp-output-plan-staging.txt"},
				{"exp-output-plan-default.txt"},
				{"exp-output-apply-staging.txt"},
				{"exp-output-apply-default.txt"},
				{"exp-output-merge.txt"},
			},
		},
		{
			Description:   "automerge",
			RepoDir:       "automerge",
			ExpAutomerge:  true,
			ExpAutoplan:   true,
			ModifiedFiles: []string{"dir1/main.tf", "dir2/main.tf"},
			Comments: []string{
				"atlantis apply -d dir1",
				"atlantis apply -d dir2",
			},
			ExpReplies: [][]string{
				{"exp-output-autoplan.txt"},
				{"exp-output-apply-dir1.txt"},
				{"exp-output-apply-dir2.txt"},
				{"exp-output-automerge.txt"},
				{"exp-output-merge.txt"},
			},
		},
		{
			Description:   "server-side cfg",
			RepoDir:       "server-side-cfg",
			ExpAutomerge:  false,
			ExpAutoplan:   true,
			ModifiedFiles: []string{"main.tf"},
			Comments: []string{
				"atlantis apply -w staging",
				"atlantis apply -w default",
			},
			ExpReplies: [][]string{
				{"exp-output-autoplan.txt"},
				{"exp-output-apply-staging-workspace.txt"},
				{"exp-output-apply-default-workspace.txt"},
				{"exp-output-merge.txt"},
			},
		},
		{
			Description:   "workspaces parallel with atlantis.yaml",
			RepoDir:       "workspace-parallel-yaml",
			ModifiedFiles: []string{"production/main.tf", "staging/main.tf"},
			ExpAutoplan:   true,
			ExpParallel:   true,
			Comments: []string{
				"atlantis apply",
			},
			ExpReplies: [][]string{
				{"exp-output-autoplan-staging.txt", "exp-output-autoplan-production.txt"},
				{"exp-output-apply-all-staging.txt", "exp-output-apply-all-production.txt"},
				{"exp-output-merge.txt"},
			},
		},
		{
			Description:   "global apply lock disables apply commands",
			RepoDir:       "simple-yaml",
			ModifiedFiles: []string{"main.tf"},
			ApplyLock:     true,
			ExpAutoplan:   true,
			Comments: []string{
				"atlantis apply",
			},
			ExpReplies: [][]string{
				{"exp-output-autoplan.txt"},
				{"exp-output-apply-locked.txt"},
				{"exp-output-merge.txt"},
			},
		},
		{
			Description:   "omitting apply from allow commands always takes precedence",
			RepoDir:       "simple-yaml",
			ModifiedFiles: []string{"main.tf"},
			AllowCommands: []command.Name{command.Plan},
			ApplyLock:     false,
			ExpAutoplan:   true,
			Comments: []string{
				"atlantis apply",
			},
			ExpParseFailedCount:         1,
			ExpAllowResponseCommentBack: true,
			ExpReplies: [][]string{
				{"exp-output-autoplan.txt"},
				// Disabling apply is implementing by omitting it from the apply list
				// See: https://github.com/runatlantis/atlantis/pull/2877
				{"exp-output-allow-command-unknown-apply.txt"},
				{"exp-output-merge.txt"},
			},
		},
		{
			Description:   "import single project",
			RepoDir:       "import-single-project",
			ModifiedFiles: []string{"main.tf"},
			ExpAutoplan:   true,
			Comments: []string{
				"atlantis import random_id.dummy1 AA",
				"atlantis apply",
				"atlantis import random_id.dummy2 BB",
				"atlantis plan",
			},
			ExpReplies: [][]string{
				{"exp-output-autoplan.txt"},
				{"exp-output-import-dummy1.txt"},
				{"exp-output-apply-no-projects.txt"},
				{"exp-output-import-dummy2.txt"},
				{"exp-output-plan-again.txt"},
				{"exp-output-merge.txt"},
			},
		},
		{
			Description: "import workspace",
			RepoDir:     "import-workspace",
			Comments: []string{
				"atlantis import -d dir1 -w ops 'random_id.dummy1[0]' AA",
				"atlantis import -p dir1-ops 'random_id.dummy2[0]' BB",
				"atlantis plan -p dir1-ops",
			},
			ExpReplies: [][]string{
				{"exp-output-import-dir1-ops-dummy1.txt"},
				{"exp-output-import-dir1-ops-dummy2.txt"},
				{"exp-output-plan.txt"},
				{"exp-output-merge.txt"},
			},
		},
		{
			Description:   "import single project with -var",
			RepoDir:       "import-single-project-var",
			ModifiedFiles: []string{"main.tf"},
			ExpAutoplan:   true,
			Comments: []string{
				"atlantis import 'random_id.for_each[\"overridden\"]' AA -- -var var=overridden",
				"atlantis import random_id.count[0] BB",
				"atlantis plan -- -var var=overridden",
			},
			ExpReplies: [][]string{
				{"exp-output-autoplan.txt"},
				{"exp-output-import-foreach.txt"},
				{"exp-output-import-count.txt"},
				{"exp-output-plan-again.txt"},
				{"exp-output-merge.txt"},
			},
		},
		{
			Description:   "import multiple project",
			RepoDir:       "import-multiple-project",
			ModifiedFiles: []string{"dir1/main.tf", "dir2/main.tf"},
			ExpAutoplan:   true,
			Comments: []string{
				"atlantis import random_id.dummy1 AA",
				"atlantis import -d dir1 random_id.dummy1 AA",
				"atlantis plan",
			},
			ExpReplies: [][]string{
				{"exp-output-autoplan.txt"},
				{"exp-output-import-multiple-projects.txt"},
				{"exp-output-import-dummy1.txt"},
				{"exp-output-plan-again.txt"},
				{"exp-output-merge.txt"},
			},
		},
		{
			Description:   "state rm single project",
			RepoDir:       "state-rm-single-project",
			ModifiedFiles: []string{"main.tf"},
			ExpAutoplan:   true,
			Comments: []string{
				"atlantis import random_id.simple AA",
				"atlantis import 'random_id.for_each[\"overridden\"]' BB -- -var var=overridden",
				"atlantis import random_id.count[0] BB",
				"atlantis plan -- -var var=overridden",
				"atlantis state rm 'random_id.for_each[\"overridden\"]' -- -lock=false",
				"atlantis state rm random_id.count[0] random_id.simple",
				"atlantis plan -- -var var=overridden",
			},
			ExpReplies: [][]string{
				{"exp-output-autoplan.txt"},
				{"exp-output-import-simple.txt"},
				{"exp-output-import-foreach.txt"},
				{"exp-output-import-count.txt"},
				{"exp-output-plan.txt"},
				{"exp-output-state-rm-foreach.txt"},
				{"exp-output-state-rm-multiple.txt"},
				{"exp-output-plan-again.txt"},
				{"exp-output-merged.txt"},
			},
		},
		{
			Description: "state rm workspace",
			RepoDir:     "state-rm-workspace",
			Comments: []string{
				"atlantis import -p dir1-ops 'random_id.dummy1[0]' AA",
				"atlantis plan -p dir1-ops",
				"atlantis state rm -p dir1-ops 'random_id.dummy1[0]'",
				"atlantis plan -p dir1-ops",
			},
			ExpReplies: [][]string{
				{"exp-output-import-dummy1.txt"},
				{"exp-output-plan.txt"},
				{"exp-output-state-rm-dummy1.txt"},
				{"exp-output-plan-again.txt"},
				{"exp-output-merge.txt"},
			},
		},
		{
			Description:   "state rm multiple project",
			RepoDir:       "state-rm-multiple-project",
			ModifiedFiles: []string{"dir1/main.tf", "dir2/main.tf"},
			ExpAutoplan:   true,
			Comments: []string{
				"atlantis import -d dir1 random_id.dummy AA",
				"atlantis import -d dir2 random_id.dummy BB",
				"atlantis plan",
				"atlantis state rm random_id.dummy",
				"atlantis plan",
			},
			ExpReplies: [][]string{
				{"exp-output-autoplan.txt"},
				{"exp-output-import-dummy1.txt"},
				{"exp-output-import-dummy2.txt"},
				{"exp-output-plan.txt"},
				{"exp-output-state-rm-multiple-projects.txt"},
				{"exp-output-plan-again.txt"},
				{"exp-output-merged.txt"},
			},
		},
	}
	for _, c := range cases {
		t.Run(c.Description, func(t *testing.T) {
			RegisterMockTestingT(t)

			// reset userConfig
			userConfig = server.UserConfig{}

			opt := setupOption{
				repoConfigFile:          c.RepoConfigFile,
				allowCommands:           c.AllowCommands,
				disableAutoplan:         c.DisableAutoplan,
				disablePreWorkflowHooks: c.DisablePreWorkflowHooks,
			}
			ctrl, vcsClient, githubGetter, atlantisWorkspace := setupE2E(t, c.RepoDir, opt)
			// Set the repo to be cloned through the testing backdoor.
			repoDir, headSHA := initializeRepo(t, c.RepoDir)
			atlantisWorkspace.TestingOverrideHeadCloneURL = fmt.Sprintf("file://%s", repoDir)

			// Setup test dependencies.
			w := httptest.NewRecorder()
			When(githubGetter.GetPullRequest(
				Any[logging.SimpleLogging](), Any[models.Repo](), Any[int]())).ThenReturn(GitHubPullRequestParsed(headSHA), nil)
			When(vcsClient.GetModifiedFiles(
				Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest]())).ThenReturn(c.ModifiedFiles, nil)

			// First, send the open pull request event which triggers autoplan.
			pullOpenedReq := GitHubPullRequestOpenedEvent(t, headSHA)
			ctrl.Post(w, pullOpenedReq)
			ResponseContains(t, w, 200, "Processing...")

			// Create global apply lock if required
			if c.ApplyLock {
				_, _ = applyLocker.LockApply()
			}

			// Now send any other comments.
			for _, comment := range c.Comments {
				commentReq := GitHubCommentEvent(t, comment)
				w = httptest.NewRecorder()
				ctrl.Post(w, commentReq)
				if c.ExpAllowResponseCommentBack {
					ResponseContains(t, w, 200, "Commenting back on pull request")
				} else {
					ResponseContains(t, w, 200, "Processing...")
				}
			}

			// Send the "pull closed" event which would be triggered by the
			// automerge or a manual merge.
			pullClosedReq := GitHubPullRequestClosedEvent(t)
			w = httptest.NewRecorder()
			ctrl.Post(w, pullClosedReq)
			ResponseContains(t, w, 200, "Pull request cleaned successfully")

			expNumHooks := len(c.Comments) - c.ExpParseFailedCount
			// if auto plan is disabled, hooks will not be called on pull request opened event
			if !c.DisableAutoplan {
				expNumHooks++
			}
			// Let's verify the pre-workflow hook was called for each comment including the pull request opened event
			if !c.DisablePreWorkflowHooks {
				mockPreWorkflowHookRunner.VerifyWasCalled(Times(expNumHooks)).Run(Any[models.WorkflowHookCommandContext](),
					Eq("some dummy command"), Any[string](), Any[string](), Any[string]())
			}
			// Let's verify the post-workflow hook was called for each comment including the pull request opened event
			mockPostWorkflowHookRunner.VerifyWasCalled(Times(expNumHooks)).Run(Any[models.WorkflowHookCommandContext](),
				Eq("some post dummy command"), Any[string](), Any[string](), Any[string]())

			// Now we're ready to verify Atlantis made all the comments back (or
			// replies) that we expect.  We expect each plan to have 1 comment,
			// and apply have 1 for each comment
			expNumReplies := len(c.Comments)

			// If there are locks to delete at the end, that will take a comment
			if !c.ExpNoLocksToDelete {
				expNumReplies++
			}

			if c.ExpAutoplan {
				expNumReplies++
			}

			if c.ExpAutomerge {
				expNumReplies++
			}

			_, _, _, actReplies, _ := vcsClient.VerifyWasCalled(Times(expNumReplies)).CreateComment(
				Any[logging.SimpleLogging](), Any[models.Repo](), Any[int](), Any[string](), Any[string]()).GetAllCapturedArguments()
			Assert(t, len(c.ExpReplies) == len(actReplies), "missing expected replies, got %d but expected %d", len(actReplies), len(c.ExpReplies))
			for i, expReply := range c.ExpReplies {
				assertCommentEquals(t, expReply, actReplies[i], c.RepoDir, c.ExpParallel)
			}

			if c.ExpAutomerge {
				// Verify that the merge API call was made.
				vcsClient.VerifyWasCalledOnce().MergePull(Any[logging.SimpleLogging](), Any[models.PullRequest](), Any[models.PullRequestOptions]())
			} else {
				vcsClient.VerifyWasCalled(Never()).MergePull(Any[logging.SimpleLogging](), Any[models.PullRequest](), Any[models.PullRequestOptions]())
			}
		})
	}
}

func TestSimpleWorkflow_terraformLockFile(t *testing.T) {

	if testing.Short() {
		t.SkipNow()
	}
	// Ensure we have >= TF 0.14 locally.
	ensureRunning014(t)

	cases := []struct {
		Description string
		// RepoDir is relative to testdata/test-repos.
		RepoDir string
		// ModifiedFiles are the list of files that have been modified in this
		// pull request.
		ModifiedFiles []string
		// ExpAutoplan is true if we expect Atlantis to autoplan.
		ExpAutoplan bool
		// Comments are what our mock user writes to the pull request.
		Comments []string
		// ExpReplies is a list of files containing the expected replies that
		// Atlantis writes to the pull request in order. A reply from a parallel operation
		// will be matched using a substring check.
		ExpReplies [][]string
		// LockFileTracked deterims if the `.terraform.lock.hcl` file is tracked in git
		// if this is true we dont expect the lockfile to be modified by terraform init
		// if false we expect the lock file to be updated
		LockFileTracked bool
	}{
		{
			Description:   "simple with plan comment lockfile staged",
			RepoDir:       "simple-with-lockfile",
			ModifiedFiles: []string{"main.tf"},
			ExpAutoplan:   true,
			Comments: []string{
				"atlantis plan",
			},
			ExpReplies: [][]string{
				{"exp-output-autoplan.txt"},
				{"exp-output-plan.txt"},
			},
			LockFileTracked: true,
		},
		{
			Description:   "simple with plan comment lockfile not staged",
			RepoDir:       "simple-with-lockfile",
			ModifiedFiles: []string{"main.tf"},
			Comments: []string{
				"atlantis plan",
			},
			ExpReplies: [][]string{
				{"exp-output-autoplan.txt"},
				{"exp-output-plan.txt"},
			},
			LockFileTracked: false,
		},
		{
			Description:   "Modified .terraform.lock.hcl triggers autoplan ",
			RepoDir:       "simple-with-lockfile",
			ModifiedFiles: []string{".terraform.lock.hcl"},
			ExpAutoplan:   true,
			Comments: []string{
				"atlantis plan",
			},
			ExpReplies: [][]string{
				{"exp-output-autoplan.txt"},
				{"exp-output-plan.txt"},
			},
			LockFileTracked: true,
		},
	}
	for _, c := range cases {
		t.Run(c.Description, func(t *testing.T) {
			RegisterMockTestingT(t)

			// reset userConfig
			userConfig = server.UserConfig{}

			ctrl, vcsClient, githubGetter, atlantisWorkspace := setupE2E(t, c.RepoDir, setupOption{})
			// Set the repo to be cloned through the testing backdoor.
			repoDir, headSHA := initializeRepo(t, c.RepoDir)

			oldLockFilePath, err := filepath.Abs(filepath.Join("testdata", "null_provider_lockfile_old_version"))
			Ok(t, err)
			oldLockFileContent, err := os.ReadFile(oldLockFilePath)
			Ok(t, err)

			if c.LockFileTracked {
				runCmd(t, "", "cp", oldLockFilePath, fmt.Sprintf("%s/.terraform.lock.hcl", repoDir))
				runCmd(t, repoDir, "git", "add", ".terraform.lock.hcl")
				runCmd(t, repoDir, "git", "commit", "-am", "stage .terraform.lock.hcl")
				// Update target sha since there's now an extra commit
				headSHA = strings.TrimSpace(runCmd(t, repoDir, "git", "rev-parse", "HEAD"))
			}

			atlantisWorkspace.TestingOverrideHeadCloneURL = fmt.Sprintf("file://%s", repoDir)

			// Setup test dependencies.
			w := httptest.NewRecorder()
			When(githubGetter.GetPullRequest(Any[logging.SimpleLogging](), Any[models.Repo](), Any[int]())).ThenReturn(GitHubPullRequestParsed(headSHA), nil)
			When(vcsClient.GetModifiedFiles(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest]())).ThenReturn(c.ModifiedFiles, nil)

			// First, send the open pull request event which triggers autoplan.
			pullOpenedReq := GitHubPullRequestOpenedEvent(t, headSHA)
			ctrl.Post(w, pullOpenedReq)
			ResponseContains(t, w, 200, "Processing...")

			// check lock file content
			actualLockFileContent, err := os.ReadFile(fmt.Sprintf("%s/repos/runatlantis/atlantis-tests/2/default/.terraform.lock.hcl", atlantisWorkspace.DataDir))
			Ok(t, err)
			if c.LockFileTracked {
				if string(oldLockFileContent) != string(actualLockFileContent) {
					t.Error("Expected terraform.lock.hcl file not to be different as it has been staged")
					t.FailNow()
				}
			} else {
				if string(oldLockFileContent) == string(actualLockFileContent) {
					t.Error("Expected terraform.lock.hcl file to be different as it should have been updated")
					t.FailNow()
				}
			}

			if !c.LockFileTracked {
				// replace the lock file generated by the previous init to simulate
				// dependencies needing updating in a latter plan
				runCmd(t, "", "cp", oldLockFilePath, fmt.Sprintf("%s/repos/runatlantis/atlantis-tests/2/default/.terraform.lock.hcl", atlantisWorkspace.DataDir))
			}

			// Now send any other comments.
			for _, comment := range c.Comments {
				commentReq := GitHubCommentEvent(t, comment)
				w = httptest.NewRecorder()
				ctrl.Post(w, commentReq)
				ResponseContains(t, w, 200, "Processing...")
			}

			// check lock file content
			actualLockFileContent, err = os.ReadFile(fmt.Sprintf("%s/repos/runatlantis/atlantis-tests/2/default/.terraform.lock.hcl", atlantisWorkspace.DataDir))
			Ok(t, err)
			if c.LockFileTracked {
				if string(oldLockFileContent) != string(actualLockFileContent) {
					t.Error("Expected terraform.lock.hcl file not to be different as it has been staged")
					t.FailNow()
				}
			} else {
				if string(oldLockFileContent) == string(actualLockFileContent) {
					t.Error("Expected terraform.lock.hcl file to be different as it should have been updated")
					t.FailNow()
				}
			}

			// Let's verify the pre-workflow hook was called for each comment including the pull request opened event
			mockPreWorkflowHookRunner.VerifyWasCalled(Times(2)).Run(Any[models.WorkflowHookCommandContext](),
				Eq("some dummy command"), Any[string](), Any[string](), Any[string]())

			// Now we're ready to verify Atlantis made all the comments back (or
			// replies) that we expect.  We expect each plan to have 1 comment,
			// and apply have 1 for each comment plus one for the locks deleted at the
			// end.

			_, _, _, actReplies, _ := vcsClient.VerifyWasCalled(Times(2)).CreateComment(
				Any[logging.SimpleLogging](), Any[models.Repo](), Any[int](), Any[string](), Any[string]()).GetAllCapturedArguments()
			Assert(t, len(c.ExpReplies) == len(actReplies), "missing expected replies, got %d but expected %d", len(actReplies), len(c.ExpReplies))
			for i, expReply := range c.ExpReplies {
				assertCommentEquals(t, expReply, actReplies[i], c.RepoDir, false)
			}
		})
	}
}

func TestGitHubWorkflowWithPolicyCheck(t *testing.T) {
	if testing.Short() {
		t.SkipNow()
	}
	// Ensure we have >= TF 0.14 locally.
	ensureRunning014(t)
	// Ensure we have conftest locally.
	ensureRunningConftest(t)

	cases := []struct {
		Description string
		// RepoDir is relative to testdata/test-repos.
		RepoDir string
		// ModifiedFiles are the list of files that have been modified in this
		// pull request.
		ModifiedFiles []string
		// Comments are what our mock user writes to the pull request.
		Comments []string
		// PolicyCheck is true if we expect Atlantis to run policy checking
		PolicyCheck bool
		// ExpAutomerge is true if we expect Atlantis to automerge.
		ExpAutomerge bool
		// ExpAutoplan is true if we expect Atlantis to autoplan.
		ExpAutoplan bool
		// ExpPolicyChecks is true if we expect Atlantis to execute policy checks
		ExpPolicyChecks bool
		// ExpQuietPolicyChecks is true if we expect Atlantis to exclude policy check output
		// when there's no error
		ExpQuietPolicyChecks bool
		// ExpQuietPolicyCheckFailure is true when we expect Atlantis to post back policy check failures
		// even when QuietPolicyChecks is enabled
		ExpQuietPolicyCheckFailure bool
		// ExpParallel is true if we expect Atlantis to run parallel plans or applies.
		ExpParallel bool
		// ExpReplies is a list of files containing the expected replies that
		// Atlantis writes to the pull request in order. A reply from a parallel operation
		// will be matched using a substring check.
		ExpReplies [][]string
	}{
		{
			Description:     "1 failing policy and 1 passing policy ",
			RepoDir:         "policy-checks-multi-projects",
			ModifiedFiles:   []string{"dir1/main.tf,", "dir2/main.tf"},
			PolicyCheck:     true,
			ExpAutoplan:     true,
			ExpPolicyChecks: true,
			Comments: []string{
				"atlantis apply",
			},
			ExpReplies: [][]string{
				{"exp-output-autoplan.txt"},
				{"exp-output-auto-policy-check.txt"},
				{"exp-output-apply.txt"},
				{"exp-output-merge.txt"},
			},
		},
		{
			Description:                "1 failing policy and 1 passing policy with --quiet-policy-checks",
			RepoDir:                    "policy-checks-multi-projects",
			ModifiedFiles:              []string{"dir1/main.tf,", "dir2/main.tf"},
			PolicyCheck:                true,
			ExpAutoplan:                true,
			ExpPolicyChecks:            true,
			ExpQuietPolicyChecks:       true,
			ExpQuietPolicyCheckFailure: true,
			Comments: []string{
				"atlantis apply",
			},
			ExpReplies: [][]string{
				{"exp-output-autoplan.txt"},
				{"exp-output-auto-policy-check-quiet.txt"},
				{"exp-output-apply.txt"},
				{"exp-output-merge.txt"},
			},
		},
		{
			Description:     "failing policy without policies passing using extra args",
			RepoDir:         "policy-checks-extra-args",
			ModifiedFiles:   []string{"main.tf"},
			PolicyCheck:     true,
			ExpAutoplan:     true,
			ExpPolicyChecks: true,
			Comments: []string{
				"atlantis apply",
			},
			ExpReplies: [][]string{
				{"exp-output-autoplan.txt"},
				{"exp-output-auto-policy-check.txt"},
				{"exp-output-apply-failed.txt"},
				{"exp-output-merge.txt"},
			},
		},
		{
			Description:     "failing policy without policies passing",
			RepoDir:         "policy-checks",
			ModifiedFiles:   []string{"main.tf"},
			PolicyCheck:     true,
			ExpAutoplan:     true,
			ExpPolicyChecks: true,
			Comments: []string{
				"atlantis apply",
			},
			ExpReplies: [][]string{
				{"exp-output-autoplan.txt"},
				{"exp-output-auto-policy-check.txt"},
				{"exp-output-apply-failed.txt"},
				{"exp-output-merge.txt"},
			},
		},
		{
			Description:     "failing policy without policies passing and custom run steps",
			RepoDir:         "policy-checks-custom-run-steps",
			ModifiedFiles:   []string{"main.tf"},
			PolicyCheck:     true,
			ExpAutoplan:     true,
			ExpPolicyChecks: true,
			Comments: []string{
				"atlantis apply",
			},
			ExpReplies: [][]string{
				{"exp-output-autoplan.txt"},
				{"exp-output-auto-policy-check.txt"},
				{"exp-output-apply-failed.txt"},
				{"exp-output-merge.txt"},
			},
		},
		{
			Description:     "failing policy additional apply requirements specified",
			RepoDir:         "policy-checks-apply-reqs",
			ModifiedFiles:   []string{"main.tf"},
			PolicyCheck:     true,
			ExpAutoplan:     true,
			ExpPolicyChecks: true,
			Comments: []string{
				"atlantis apply",
			},
			ExpReplies: [][]string{
				{"exp-output-autoplan.txt"},
				{"exp-output-auto-policy-check.txt"},
				{"exp-output-apply-failed.txt"},
				{"exp-output-merge.txt"},
			},
		},
		{
			Description:     "failing policy approved by non owner",
			RepoDir:         "policy-checks-diff-owner",
			ModifiedFiles:   []string{"main.tf"},
			PolicyCheck:     true,
			ExpAutoplan:     true,
			ExpPolicyChecks: true,
			Comments: []string{
				"atlantis approve_policies",
				"atlantis apply",
			},
			ExpReplies: [][]string{
				{"exp-output-autoplan.txt"},
				{"exp-output-auto-policy-check.txt"},
				{"exp-output-approve-policies.txt"},
				{"exp-output-apply-failed.txt"},
				{"exp-output-merge.txt"},
			},
		},
		{
			Description:          "successful policy checks with quiet flag enabled",
			RepoDir:              "policy-checks-success-silent",
			ModifiedFiles:        []string{"main.tf"},
			PolicyCheck:          true,
			ExpAutoplan:          true,
			ExpPolicyChecks:      true,
			ExpQuietPolicyChecks: true,
			Comments: []string{
				"atlantis apply",
			},
			ExpReplies: [][]string{
				{"exp-output-autoplan.txt"},
				{"exp-output-apply.txt"},
				{"exp-output-merge.txt"},
			},
		},
		{
			Description:                "failing policy checks with quiet flag enabled",
			RepoDir:                    "policy-checks",
			ModifiedFiles:              []string{"main.tf"},
			PolicyCheck:                true,
			ExpAutoplan:                true,
			ExpPolicyChecks:            true,
			ExpQuietPolicyChecks:       true,
			ExpQuietPolicyCheckFailure: true,
			Comments: []string{
				"atlantis apply",
			},
			ExpReplies: [][]string{
				{"exp-output-autoplan.txt"},
				{"exp-output-auto-policy-check.txt"},
				{"exp-output-apply-failed.txt"},
				{"exp-output-merge.txt"},
			},
		},
		{
			Description:     "failing policy with approval and policy approval clear",
			RepoDir:         "policy-checks-clear-approval",
			ModifiedFiles:   []string{"main.tf"},
			PolicyCheck:     true,
			ExpAutoplan:     true,
			ExpPolicyChecks: true,
			Comments: []string{
				"atlantis approve_policies",
				"atlantis approve_policies --clear-policy-approval",
				"atlantis apply",
			},
			ExpReplies: [][]string{
				{"exp-output-autoplan.txt"},
				{"exp-output-auto-policy-check.txt"},
				{"exp-output-approve-policies-success.txt"},
				{"exp-output-approve-policies-clear.txt"},
				{"exp-output-apply-failed.txt"},
				{"exp-output-merge.txt"},
			},
		},
		{
			Description:     "policy checking disabled on specific repo",
			RepoDir:         "policy-checks-disabled-repo",
			ModifiedFiles:   []string{"main.tf"},
			PolicyCheck:     true,
			ExpAutoplan:     true,
			ExpPolicyChecks: false,
			Comments: []string{
				"atlantis apply",
			},
			ExpReplies: [][]string{
				{"exp-output-autoplan.txt"},
				{"exp-output-apply.txt"},
				{"exp-output-merge.txt"},
			},
		},
		{
			Description:     "policy checking disabled on specific repo server side",
			RepoDir:         "policy-checks-disabled-repo-server-side",
			ModifiedFiles:   []string{"main.tf"},
			PolicyCheck:     true,
			ExpAutoplan:     true,
			ExpPolicyChecks: false,
			Comments: []string{
				"atlantis apply",
			},
			ExpReplies: [][]string{
				{"exp-output-autoplan.txt"},
				{"exp-output-apply.txt"},
				{"exp-output-merge.txt"},
			},
		},
		{
			Description:     "policy checking enabled on specific repo but disabled globally",
			RepoDir:         "policy-checks-enabled-repo",
			ModifiedFiles:   []string{"main.tf"},
			PolicyCheck:     false,
			ExpAutoplan:     true,
			ExpPolicyChecks: false,
			Comments: []string{
				"atlantis apply",
			},
			ExpReplies: [][]string{
				{"exp-output-autoplan.txt"},
				{"exp-output-apply.txt"},
				{"exp-output-merge.txt"},
			},
		},
		{
			Description:     "policy checking enabled on specific repo server side but disabled globally",
			RepoDir:         "policy-checks-enabled-repo-server-side",
			ModifiedFiles:   []string{"main.tf"},
			PolicyCheck:     false,
			ExpAutoplan:     true,
			ExpPolicyChecks: false,
			Comments: []string{
				"atlantis apply",
			},
			ExpReplies: [][]string{
				{"exp-output-autoplan.txt"},
				{"exp-output-apply.txt"},
				{"exp-output-merge.txt"},
			},
		},
		{
			Description:     "policy checking disabled on previous regex match but not on repo",
			RepoDir:         "policy-checks-disabled-previous-match",
			ModifiedFiles:   []string{"main.tf"},
			PolicyCheck:     true,
			ExpAutoplan:     true,
			ExpPolicyChecks: false,
			Comments: []string{
				"atlantis apply",
			},
			ExpReplies: [][]string{
				{"exp-output-autoplan.txt"},
				{"exp-output-apply.txt"},
				{"exp-output-merge.txt"},
			},
		},
	}

	for _, c := range cases {
		t.Run(c.Description, func(t *testing.T) {
			RegisterMockTestingT(t)

			// reset userConfig
			userConfig = server.UserConfig{}
			userConfig.EnablePolicyChecksFlag = c.PolicyCheck
			userConfig.QuietPolicyChecks = c.ExpQuietPolicyChecks

			ctrl, vcsClient, githubGetter, atlantisWorkspace := setupE2E(t, c.RepoDir, setupOption{userConfig: userConfig})

			// Set the repo to be cloned through the testing backdoor.
			repoDir, headSHA := initializeRepo(t, c.RepoDir)
			atlantisWorkspace.TestingOverrideHeadCloneURL = fmt.Sprintf("file://%s", repoDir)

			// Setup test dependencies.
			w := httptest.NewRecorder()
			When(vcsClient.PullIsMergeable(
				Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), Eq("atlantis-test"), Eq([]string{}))).ThenReturn(models.MergeableStatus{
				IsMergeable: true,
			}, nil)
			When(vcsClient.PullIsApproved(
				Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest]())).ThenReturn(models.ApprovalStatus{
				IsApproved: true,
			}, nil)
			When(githubGetter.GetPullRequest(
				Any[logging.SimpleLogging](), Any[models.Repo](), Any[int]())).ThenReturn(GitHubPullRequestParsed(headSHA), nil)
			When(vcsClient.GetModifiedFiles(
				Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest]())).ThenReturn(c.ModifiedFiles, nil)

			// First, send the open pull request event which triggers autoplan.
			pullOpenedReq := GitHubPullRequestOpenedEvent(t, headSHA)
			ctrl.Post(w, pullOpenedReq)
			ResponseContains(t, w, 200, "Processing...")

			// Now send any other comments.
			for _, comment := range c.Comments {
				commentReq := GitHubCommentEvent(t, comment)
				w = httptest.NewRecorder()
				ctrl.Post(w, commentReq)
				ResponseContains(t, w, 200, "Processing...")
			}

			// Send the "pull closed" event which would be triggered by the
			// automerge or a manual merge.
			pullClosedReq := GitHubPullRequestClosedEvent(t)
			w = httptest.NewRecorder()
			ctrl.Post(w, pullClosedReq)
			ResponseContains(t, w, 200, "Pull request cleaned successfully")

			// Now we're ready to verify Atlantis made all the comments back (or
			// replies) that we expect.  We expect each plan to have 2 comments,
			// one for plan one for policy check and apply have 1 for each
			// comment plus one for the locks deleted at the end.
			expNumReplies := len(c.Comments) + 1

			if c.ExpAutoplan {
				expNumReplies++
				expNumReplies++
			}

			var planRegex = regexp.MustCompile("plan")
			for _, comment := range c.Comments {
				if planRegex.MatchString(comment) {
					expNumReplies++
				}
			}

			if c.ExpAutomerge {
				expNumReplies++
			}

			if c.ExpQuietPolicyChecks && !c.ExpQuietPolicyCheckFailure {
				expNumReplies--
			}

			if !c.ExpPolicyChecks {
				expNumReplies--
			}
			_, _, _, actReplies, _ := vcsClient.VerifyWasCalled(Times(expNumReplies)).CreateComment(
				Any[logging.SimpleLogging](), Any[models.Repo](), Any[int](), Any[string](), Any[string]()).GetAllCapturedArguments()

			Assert(t, len(c.ExpReplies) == len(actReplies), "missing expected replies, got %d but expected %d", len(actReplies), len(c.ExpReplies))
			for i, expReply := range c.ExpReplies {
				assertCommentEquals(t, expReply, actReplies[i], c.RepoDir, c.ExpParallel)
			}

			if c.ExpAutomerge {
				// Verify that the merge API call was made.
				vcsClient.VerifyWasCalledOnce().MergePull(Any[logging.SimpleLogging](), Any[models.PullRequest](), Any[models.PullRequestOptions]())
			} else {
				vcsClient.VerifyWasCalled(Never()).MergePull(Any[logging.SimpleLogging](), Any[models.PullRequest](), Any[models.PullRequestOptions]())
			}
		})
	}
}

type setupOption struct {
	repoConfigFile          string
	allowCommands           []command.Name
	disableAutoplan         bool
	disablePreWorkflowHooks bool
	userConfig              server.UserConfig
}

func setupE2E(t *testing.T, repoDir string, opt setupOption) (events_controllers.VCSEventsController, *vcsmocks.MockClient, *mocks.MockGithubPullGetter, *events.FileWorkspace) {
	allowForkPRs := false
	discardApprovalOnPlan := true
	dataDir, binDir, cacheDir := mkSubDirs(t)
	// Mocks.
	e2eVCSClient := vcsmocks.NewMockClient()
	e2eStatusUpdater := &events.DefaultCommitStatusUpdater{Client: e2eVCSClient}
	e2eGithubGetter := mocks.NewMockGithubPullGetter()
	e2eGitlabGetter := mocks.NewMockGitlabMergeRequestGetter()
	projectCmdOutputHandler := jobmocks.NewMockProjectCommandOutputHandler()

	// Real dependencies.
	logging.SuppressDefaultLogging()
	logger := logging.NewNoopLogger(t)

	eventParser := &events.EventParser{
		GithubUser:  "github-user",
		GithubToken: "github-token",
		GitlabUser:  "gitlab-user",
		GitlabToken: "gitlab-token",
	}
	allowCommands := command.AllCommentCommands
	if opt.allowCommands != nil {
		allowCommands = opt.allowCommands
	}
	disableApply := true
	disableGlobalApplyLock := false
	for _, allowCommand := range allowCommands {
		if allowCommand == command.Apply {
			disableApply = false
			break
		}
	}
	commentParser := &events.CommentParser{
		GithubUser:     "github-user",
		GitlabUser:     "gitlab-user",
		ExecutableName: "atlantis",
		AllowCommands:  allowCommands,
	}

	mockDownloader := terraform_mocks.NewMockDownloader()
	distribution := terraform.NewDistributionTerraformWithDownloader(mockDownloader)

	terraformClient, err := tfclient.NewClient(logger, distribution, binDir, cacheDir, "", "", "", "default-tf-version", "https://releases.hashicorp.com", true, false, projectCmdOutputHandler)
	Ok(t, err)
	b, err := boltdb.New(dataDir)
	Ok(t, err)
	database := b
	lockingClient := locking.NewClient(b)
	noOpLocker := locking.NewNoOpLocker()
	applyLocker = locking.NewApplyClient(b, disableApply, disableGlobalApplyLock)
	projectLocker := &events.DefaultProjectLocker{
		Locker:     lockingClient,
		NoOpLocker: noOpLocker,
		VCSClient:  e2eVCSClient,
	}
	workingDir := &events.FileWorkspace{
		DataDir:                     dataDir,
		TestingOverrideHeadCloneURL: "override-me",
	}
	var preWorkflowHooks []*valid.WorkflowHook
	if !opt.disablePreWorkflowHooks {
		preWorkflowHooks = []*valid.WorkflowHook{
			{
				StepName:   "global_hook",
				RunCommand: "some dummy command",
			},
		}
	}

	defaultTFDistribution := terraformClient.DefaultDistribution()
	defaultTFVersion := terraformClient.DefaultVersion()
	locker := events.NewDefaultWorkingDirLocker()
	parser := &config.ParserValidator{}

	globalCfgArgs := valid.GlobalCfgArgs{
		RepoConfigFile:       opt.repoConfigFile,
		AllowAllRepoSettings: true,
		PreWorkflowHooks:     preWorkflowHooks,
		PostWorkflowHooks: []*valid.WorkflowHook{
			{
				StepName:   "global_hook",
				RunCommand: "some post dummy command",
			},
		},
		PolicyCheckEnabled: userConfig.EnablePolicyChecksFlag,
	}
	globalCfg := valid.NewGlobalCfgFromArgs(globalCfgArgs)
	expCfgPath := filepath.Join(absRepoPath(t, repoDir), "repos.yaml")
	if _, err := os.Stat(expCfgPath); err == nil {
		globalCfg, err = parser.ParseGlobalCfg(expCfgPath, globalCfg)
		Ok(t, err)
	}
	drainer := &events.Drainer{}

	parallelPoolSize := 1
	silenceNoProjects := false

	disableUnlockLabel := "do-not-unlock"

	statusUpdater := runtimemocks.NewMockStatusUpdater()
	commitStatusUpdater := mocks.NewMockCommitStatusUpdater()
	asyncTfExec := runtimemocks.NewMockAsyncTFExec()

	mockPreWorkflowHookRunner = runtimemocks.NewMockPreWorkflowHookRunner()
	preWorkflowHookURLGenerator := mocks.NewMockPreWorkflowHookURLGenerator()
	preWorkflowHooksCommandRunner := &events.DefaultPreWorkflowHooksCommandRunner{
		VCSClient:             e2eVCSClient,
		GlobalCfg:             globalCfg,
		WorkingDirLocker:      locker,
		WorkingDir:            workingDir,
		PreWorkflowHookRunner: mockPreWorkflowHookRunner,
		CommitStatusUpdater:   commitStatusUpdater,
		Router:                preWorkflowHookURLGenerator,
	}

	mockPostWorkflowHookRunner = runtimemocks.NewMockPostWorkflowHookRunner()
	postWorkflowHookURLGenerator := mocks.NewMockPostWorkflowHookURLGenerator()
	postWorkflowHooksCommandRunner := &events.DefaultPostWorkflowHooksCommandRunner{
		VCSClient:              e2eVCSClient,
		GlobalCfg:              globalCfg,
		WorkingDirLocker:       locker,
		WorkingDir:             workingDir,
		PostWorkflowHookRunner: mockPostWorkflowHookRunner,
		CommitStatusUpdater:    commitStatusUpdater,
		Router:                 postWorkflowHookURLGenerator,
	}
	statsScope := metricstest.NewLoggingScope(t, logger, "atlantis")

	projectCommandBuilder := events.NewProjectCommandBuilder(
		userConfig.EnablePolicyChecksFlag,
		parser,
		&events.DefaultProjectFinder{},
		e2eVCSClient,
		workingDir,
		locker,
		globalCfg,
		&events.DefaultPendingPlanFinder{},
		commentParser,
		false,
		false,
		false,
		false,
		false,
		"",
		"**/*.tf,**/*.tfvars,**/*.tfvars.json,**/terragrunt.hcl,**/.terraform.lock.hcl",
		false,
		false,
		false,
		"auto",
		statsScope,
		terraformClient,
	)

	showStepRunner, err := runtime.NewShowStepRunner(terraformClient, defaultTFDistribution, defaultTFVersion)

	Ok(t, err)

	conftextExec := policy.NewConfTestExecutorWorkflow(logger, binDir, mock_policy.NewMockDownloader())

	// swapping out version cache to something that always returns local conftest
	// binary
	conftextExec.VersionCache = &LocalConftestCache{}

	policyCheckRunner, err := runtime.NewPolicyCheckStepRunner(
		defaultTFDistribution,
		defaultTFVersion,
		conftextExec,
	)

	Ok(t, err)

	projectCommandRunner := &events.DefaultProjectCommandRunner{
		VcsClient:        e2eVCSClient,
		Locker:           projectLocker,
		LockURLGenerator: &mockLockURLGenerator{},
		InitStepRunner: &runtime.InitStepRunner{
			TerraformExecutor:     terraformClient,
			DefaultTFDistribution: defaultTFDistribution,
			DefaultTFVersion:      defaultTFVersion,
		},
		PlanStepRunner: runtime.NewPlanStepRunner(
			terraformClient,
			defaultTFDistribution,
			defaultTFVersion,
			statusUpdater,
			asyncTfExec,
		),
		ShowStepRunner:        showStepRunner,
		PolicyCheckStepRunner: policyCheckRunner,
		ApplyStepRunner: &runtime.ApplyStepRunner{
			TerraformExecutor: terraformClient,
		},
		ImportStepRunner:  runtime.NewImportStepRunner(terraformClient, defaultTFDistribution, defaultTFVersion),
		StateRmStepRunner: runtime.NewStateRmStepRunner(terraformClient, defaultTFDistribution, defaultTFVersion),
		RunStepRunner: &runtime.RunStepRunner{
			TerraformExecutor:       terraformClient,
			DefaultTFDistribution:   defaultTFDistribution,
			DefaultTFVersion:        defaultTFVersion,
			ProjectCmdOutputHandler: projectCmdOutputHandler,
		},
		WorkingDir:       workingDir,
		Webhooks:         &mockWebhookSender{},
		WorkingDirLocker: locker,
		CommandRequirementHandler: &events.DefaultCommandRequirementHandler{
			WorkingDir: workingDir,
		},
	}

	dbUpdater := &events.DBUpdater{
		Database: database,
	}

	pullUpdater := &events.PullUpdater{
		HidePrevPlanComments: false,
		VCSClient:            e2eVCSClient,
		MarkdownRenderer: events.NewMarkdownRenderer(
			false,                            // gitlabSupportsCommonMark
			false,                            // disableApplyAll
			false,                            // disableApply
			false,                            // disableMarkdownFolding
			false,                            // disableRepoLocking
			false,                            // enableDiffMarkdownFormat
			"",                               // markdownTemplateOverridesDir
			"atlantis",                       // executableName
			false,                            // hideUnchangedPlanComments
			opt.userConfig.QuietPolicyChecks, // quietPolicyChecks
		),
	}

	autoMerger := &events.AutoMerger{
		VCSClient:       e2eVCSClient,
		GlobalAutomerge: false,
	}

	policyCheckCommandRunner := events.NewPolicyCheckCommandRunner(
		dbUpdater,
		pullUpdater,
		e2eStatusUpdater,
		projectCommandRunner,
		parallelPoolSize,
		false,
		userConfig.QuietPolicyChecks,
	)

	e2ePullReqStatusFetcher := vcs.NewPullReqStatusFetcher(e2eVCSClient, "atlantis-test", []string{})

	planCommandRunner := events.NewPlanCommandRunner(
		false,
		false,
		e2eVCSClient,
		&events.DefaultPendingPlanFinder{},
		workingDir,
		e2eStatusUpdater,
		projectCommandBuilder,
		projectCommandRunner,
		dbUpdater,
		pullUpdater,
		policyCheckCommandRunner,
		autoMerger,
		parallelPoolSize,
		silenceNoProjects,
		database,
		lockingClient,
		discardApprovalOnPlan,
		e2ePullReqStatusFetcher,
		false,
	)

	applyCommandRunner := events.NewApplyCommandRunner(
		e2eVCSClient,
		false,
		applyLocker,
		e2eStatusUpdater,
		projectCommandBuilder,
		projectCommandRunner,
		autoMerger,
		pullUpdater,
		dbUpdater,
		database,
		parallelPoolSize,
		silenceNoProjects,
		false,
		e2ePullReqStatusFetcher,
	)

	approvePoliciesCommandRunner := events.NewApprovePoliciesCommandRunner(
		e2eStatusUpdater,
		projectCommandBuilder,
		projectCommandRunner,
		pullUpdater,
		dbUpdater,
		silenceNoProjects,
		false,
		e2eVCSClient,
	)

	unlockCommandRunner := events.NewUnlockCommandRunner(
		mocks.NewMockDeleteLockCommand(),
		e2eVCSClient,
		silenceNoProjects,
		disableUnlockLabel,
	)

	versionCommandRunner := events.NewVersionCommandRunner(
		pullUpdater,
		projectCommandBuilder,
		projectCommandRunner,
		parallelPoolSize,
		silenceNoProjects,
	)

	importCommandRunner := events.NewImportCommandRunner(
		pullUpdater,
		e2ePullReqStatusFetcher,
		projectCommandBuilder,
		projectCommandRunner,
		silenceNoProjects,
	)

	stateCommandRunner := events.NewStateCommandRunner(
		pullUpdater,
		projectCommandBuilder,
		projectCommandRunner,
	)

	commentCommandRunnerByCmd := map[command.Name]events.CommentCommandRunner{
		command.Plan:            planCommandRunner,
		command.Apply:           applyCommandRunner,
		command.ApprovePolicies: approvePoliciesCommandRunner,
		command.Unlock:          unlockCommandRunner,
		command.Version:         versionCommandRunner,
		command.Import:          importCommandRunner,
		command.State:           stateCommandRunner,
	}

	commandRunner := &events.DefaultCommandRunner{
		EventParser:                    eventParser,
		VCSClient:                      e2eVCSClient,
		GithubPullGetter:               e2eGithubGetter,
		GitlabMergeRequestGetter:       e2eGitlabGetter,
		Logger:                         logger,
		GlobalCfg:                      globalCfg,
		StatsScope:                     statsScope,
		AllowForkPRs:                   allowForkPRs,
		AllowForkPRsFlag:               "allow-fork-prs",
		CommentCommandRunnerByCmd:      commentCommandRunnerByCmd,
		Drainer:                        drainer,
		PreWorkflowHooksCommandRunner:  preWorkflowHooksCommandRunner,
		PostWorkflowHooksCommandRunner: postWorkflowHooksCommandRunner,
		PullStatusFetcher:              database,
		DisableAutoplan:                opt.disableAutoplan,
		CommitStatusUpdater:            commitStatusUpdater,
	}

	repoAllowlistChecker, err := events.NewRepoAllowlistChecker("*")
	Ok(t, err)

	ctrl := events_controllers.VCSEventsController{
		TestingMode:   true,
		CommandRunner: commandRunner,
		PullCleaner: &events.PullClosedExecutor{
			Locker:                   lockingClient,
			VCSClient:                e2eVCSClient,
			WorkingDir:               workingDir,
			Database:                 database,
			PullClosedTemplate:       &events.PullClosedEventTemplate{},
			LogStreamResourceCleaner: projectCmdOutputHandler,
		},
		Logger:                       logger,
		Scope:                        statsScope,
		Parser:                       eventParser,
		CommentParser:                commentParser,
		GithubWebhookSecret:          nil,
		GithubRequestValidator:       &events_controllers.DefaultGithubRequestValidator{},
		GitlabRequestParserValidator: &events_controllers.DefaultGitlabRequestParserValidator{},
		GitlabWebhookSecret:          nil,
		RepoAllowlistChecker:         repoAllowlistChecker,
		SupportedVCSHosts:            []models.VCSHostType{models.Gitlab, models.Github, models.BitbucketCloud},
		VCSClient:                    e2eVCSClient,
	}
	return ctrl, e2eVCSClient, e2eGithubGetter, workingDir
}

type mockLockURLGenerator struct{}

func (m *mockLockURLGenerator) GenerateLockURL(_ string) string {
	return "lock-url"
}

type mockWebhookSender struct{}

func (w *mockWebhookSender) Send(_ logging.SimpleLogging, _ webhooks.ApplyResult) error {
	return nil
}

func GitHubCommentEvent(t *testing.T, comment string) *http.Request {
	requestJSON, err := os.ReadFile(filepath.Join("testdata", "githubIssueCommentEvent.json"))
	Ok(t, err)
	escapedComment, err := json.Marshal(comment)
	Ok(t, err)
	requestJSON = []byte(strings.Replace(string(requestJSON), "\"###comment body###\"", string(escapedComment), 1))
	req, err := http.NewRequest("POST", "/events", bytes.NewBuffer(requestJSON))
	Ok(t, err)
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set(githubHeader, "issue_comment")
	return req
}

func GitHubPullRequestOpenedEvent(t *testing.T, headSHA string) *http.Request {
	requestJSON, err := os.ReadFile(filepath.Join("testdata", "githubPullRequestOpenedEvent.json"))
	Ok(t, err)
	// Replace sha with expected sha.
	requestJSONStr := strings.ReplaceAll(string(requestJSON), "c31fd9ea6f557ad2ea659944c3844a059b83bc5d", headSHA)
	req, err := http.NewRequest("POST", "/events", bytes.NewBuffer([]byte(requestJSONStr)))
	Ok(t, err)
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set(githubHeader, "pull_request")
	return req
}

func GitHubPullRequestClosedEvent(t *testing.T) *http.Request {
	requestJSON, err := os.ReadFile(filepath.Join("testdata", "githubPullRequestClosedEvent.json"))
	Ok(t, err)
	req, err := http.NewRequest("POST", "/events", bytes.NewBuffer(requestJSON))
	Ok(t, err)
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set(githubHeader, "pull_request")
	return req
}

func GitHubPullRequestParsed(headSHA string) *github.PullRequest {
	// headSHA can't be empty so default if not set.
	if headSHA == "" {
		headSHA = "13940d121be73f656e2132c6d7b4c8e87878ac8d"
	}
	return &github.PullRequest{
		Number:  github.Ptr(2),
		State:   github.Ptr("open"),
		HTMLURL: github.Ptr("htmlurl"),
		Head: &github.PullRequestBranch{
			Repo: &github.Repository{
				FullName: github.Ptr("runatlantis/atlantis-tests"),
				CloneURL: github.Ptr("https://github.com/runatlantis/atlantis-tests.git"),
			},
			SHA: github.Ptr(headSHA),
			Ref: github.Ptr("branch"),
		},
		Base: &github.PullRequestBranch{
			Repo: &github.Repository{
				FullName: github.Ptr("runatlantis/atlantis-tests"),
				CloneURL: github.Ptr("https://github.com/runatlantis/atlantis-tests.git"),
			},
			Ref: github.Ptr("main"),
		},
		User: &github.User{
			Login: github.Ptr("atlantisbot"),
		},
	}
}

// absRepoPath returns the absolute path to the test repo under dir repoDir.
func absRepoPath(t *testing.T, repoDir string) string {
	path, err := filepath.Abs(filepath.Join("testdata", "test-repos", repoDir))
	Ok(t, err)
	return path
}

// initializeRepo copies the repo data from testdata and initializes a new
// git repo in a temp directory. It returns that directory and a function
// to run in a defer that will delete the dir.
// The purpose of this function is to create a real git repository with a branch
// called 'branch' from the files under repoDir. This is so we can check in
// those files normally to this repo without needing a .git directory.
func initializeRepo(t *testing.T, repoDir string) (string, string) {
	originRepo := absRepoPath(t, repoDir)

	// Copy the files to the temp dir.
	destDir := t.TempDir()
	runCmd(t, "", "cp", "-r", fmt.Sprintf("%s/.", originRepo), destDir)

	// Initialize the git repo.
	runCmd(t, destDir, "git", "init")
	runCmd(t, destDir, "touch", ".gitkeep")
	runCmd(t, destDir, "git", "add", ".gitkeep")
	runCmd(t, destDir, "git", "config", "--local", "user.email", "atlantisbot@runatlantis.io")
	runCmd(t, destDir, "git", "config", "--local", "user.name", "atlantisbot")
	runCmd(t, destDir, "git", "commit", "-m", "initial commit")
	runCmd(t, destDir, "git", "checkout", "-b", "branch")
	runCmd(t, destDir, "git", "add", ".")
	runCmd(t, destDir, "git", "commit", "-am", "branch commit")
	headSHA := runCmd(t, destDir, "git", "rev-parse", "HEAD")
	headSHA = strings.Trim(headSHA, "\n")

	return destDir, headSHA
}

func runCmd(t *testing.T, dir string, name string, args ...string) string {
	cpCmd := exec.Command(name, args...)
	cpCmd.Dir = dir
	cpOut, err := cpCmd.CombinedOutput()
	Assert(t, err == nil, "err running %q: %s", strings.Join(append([]string{name}, args...), " "), cpOut)
	return string(cpOut)
}

func assertCommentEquals(t *testing.T, expReplies []string, act string, repoDir string, parallel bool) {
	t.Helper()

	// Replace all 'Creation complete after 0s [id=2135833172528078362]' strings with
	// 'Creation complete after *s [id=*******************]' so we can do a comparison.
	idRegex := regexp.MustCompile(`Creation complete after [0-9]+s \[id=[0-9]+]`)
	act = idRegex.ReplaceAllString(act, "Creation complete after *s [id=*******************]")

	// Replace all null_resource.simple{n}: .* with null_resource.simple: because
	// with multiple resources being created the logs are all out of order which
	// makes comparison impossible.
	resourceRegex := regexp.MustCompile(`null_resource\.simple(\[\d])?\d?:.*`)
	act = resourceRegex.ReplaceAllString(act, "null_resource.simple:")

	// For parallel plans and applies, do a substring match since output may be out of order
	var replyMatchesExpected func(string, string) bool
	if parallel {
		replyMatchesExpected = func(act string, expStr string) bool {
			return strings.Contains(act, expStr)
		}
	} else {
		replyMatchesExpected = func(act string, expStr string) bool {
			return expStr == act
		}
	}

	for _, expFile := range expReplies {
		exp, err := os.ReadFile(filepath.Join(absRepoPath(t, repoDir), expFile))
		Ok(t, err)
		expStr := string(exp)
		// My editor adds a newline to all the files, so if the actual comment
		// doesn't end with a newline then strip the last newline from the file's
		// contents.
		if !strings.HasSuffix(act, "\n") {
			expStr = strings.TrimSuffix(expStr, "\n")
		}

		if !replyMatchesExpected(act, expStr) {
			// If in CI, we write the diff to the console. Otherwise we write the diff
			// to file so we can use our local diff viewer.
			if os.Getenv("CI") == "true" {
				t.Logf("exp: %s, got: %s", expStr, act)
				t.FailNow()
			} else {
				actFile := filepath.Join(absRepoPath(t, repoDir), expFile+".act")
				err := os.WriteFile(actFile, []byte(act), 0600)
				Ok(t, err)
				cwd, err := os.Getwd()
				Ok(t, err)
				rel, err := filepath.Rel(cwd, actFile)
				Ok(t, err)
				t.Errorf("%q was different, wrote actual comment to %q", expFile, rel)
			}
		}
	}
}

// returns parent, bindir, cachedir, cleanup func
func mkSubDirs(t *testing.T) (string, string, string) {
	tmp := t.TempDir()
	binDir := filepath.Join(tmp, "bin")
	err := os.MkdirAll(binDir, 0700)
	Ok(t, err)

	cachedir := filepath.Join(tmp, "plugin-cache")
	err = os.MkdirAll(cachedir, 0700)
	Ok(t, err)

	return tmp, binDir, cachedir
}

// Will fail test if conftest isn't in path
func ensureRunningConftest(t *testing.T) {
	// use `conftest` command instead `conftest$version`, so tests may fail on the environment cause the output logs may become change by version.
	t.Logf("conftest check may fail depends on conftest version. please use latest stable conftest.")
	_, err := exec.LookPath(conftestCommand)
	if err != nil {
		t.Logf(`%s must be installed to run this test
- on local, please install conftest command or run 'make docker/test-all'
- on CI, please check testing-env docker image contains conftest command. see testing/Dockerfile
`, conftestCommand)
		t.FailNow()
	}
}

// Will fail test if terraform isn't in path and isn't version >= 0.14
func ensureRunning014(t *testing.T) {
	localPath, err := exec.LookPath("terraform")
	if err != nil {
		t.Log("terraform >= 0.14 must be installed to run this test")
		t.FailNow()
	}
	versionOutBytes, err := exec.Command(localPath, "version").Output() // #nosec
	if err != nil {
		t.Logf("error running terraform version: %s", err)
		t.FailNow()
	}
	versionOutput := string(versionOutBytes)
	match := versionRegex.FindStringSubmatch(versionOutput)
	if len(match) <= 1 {
		t.Logf("could not parse terraform version from %s", versionOutput)
		t.FailNow()
	}
	localVersion, err := version.NewVersion(match[1])
	Ok(t, err)
	minVersion, err := version.NewVersion("0.14.0")
	Ok(t, err)
	if localVersion.LessThan(minVersion) {
		t.Logf("must have terraform version >= %s, you have %s", minVersion, localVersion)
		t.FailNow()
	}
}

// versionRegex extracts the version from `terraform version` output.
//
//	    Terraform v0.12.0-alpha4 (2c36829d3265661d8edbd5014de8090ea7e2a076)
//		   => 0.12.0-alpha4
//
//	    Terraform v0.11.10
//		   => 0.11.10
//
//	    OpenTofu v1.0.0
//		   => 1.0.0
var versionRegex = regexp.MustCompile("(?:Terraform|OpenTofu) v(.*?)(\\s.*)?\n")
